Simulate EIP-1271 orders at creation#4355
Conversation
Runs the OrderSimulator concurrently with the cheap isValidSignature check. In shadow mode (default), logs disagreements via metrics and structured logs but returns the cheap check's result. In enforce mode, (cheap Pass, sim Fail) is upgraded to ValidationError::SimulationFailed; other combinations stay unchanged. Infra errors never reject. Covers scope from plan Tasks 3, 4 and 5: shadow-mode quadrants, enforce-mode cases, infra/skip-flag/no-sim paths.
The Shadow/Enforce distinction is a mode variant, not a property of the capability — the same simulator infrastructure is active in both modes. Keeping "shadow" only where it names a mode variant, and renaming the trait, error, mode enum, metrics, constant, config fields, and module accordingly.
Doc comments and local variable names still described the capability as 'shadow' — rewritten to reference the mode variant only where it genuinely applies (config docs, test names that exercise Shadow mode, the Shadow enum variant).
The simulator, mode, and timeout are only meaningful together. Collapsing them into a single Option<Eip1271SimConfig> field lets the call site in run.rs return None cleanly when order_simulation isn't configured, instead of passing placeholder mode/timeout values that aren't read.
… to Simulator Matches the project's convention of -ing suffix for traits (OrderValidating/OrderValidator, SignatureValidating, BalanceFetching). The former Eip1271SimConfig bundle becomes the concrete Eip1271Simulator struct, and the trait it depends on becomes Eip1271Simulating.
The helper now accepts the full simulator bundle instead of three separate (simulator, mode, timeout) args. Introduces shadow_mode_sim / enforce_mode_sim helpers to keep test call sites tight.
The constant is only used by tests; keeping it at module scope (and public) implied an API contract with the configs crate that doesn't exist. configs::orderbook::default_eip1271_sim_timeout is authoritative at runtime.
The existing EIP-1271 check is an isValidSignature call; 'cheap' was editorializing on cost rather than describing what it does. Renaming to 'signature' for the metric axis, outcome enum, and supporting names. Also collapsing sim_only_total into total by adding a 'skipped' value to the signature axis — one counter with a signature × sim matrix covers every case the two used to cover.
…configs, logs The Eip1271Simulator struct keeps its -Simulator suffix (matching OrderValidator/-Validating), but everywhere sim was used as a modifier or qualifier (enum variants, error type, metric subsystem/labels, config fields, log messages, test names) it now reads as simulation.
Operators can now opt out of the simulation at order creation on a per-chain basis without giving up the /debug/simulation endpoint. The shared mode enum stays binary (Shadow/Enforce); Disabled translates to None for OrderValidator at the wiring layer. The OrderSimulator is still constructed for the debug endpoint. Also removed the redundant impls_trait compile-check test in eip1271_simulation.rs — the impl block above already enforces that at compile time.
Mock both the signature validator and the simulator with times(0) and submit an Eip712 (EOA) order. Catches a regression where the sim is accidentally wired to run for non-1271 orders.
The seven near-identical tests covering every (signature, simulation, mode) combination collapsed into one table-driven test with a single mock-driven helper. Failure messages include a label per row so any regression still pinpoints the failing cell. Also inlined the now-unused enforce_mode_simulator helper and replaced shadow_mode_simulator with a general simulator_with_mode.
There was a problem hiding this comment.
Code Review
This pull request introduces EIP-1271 order simulation into the order validation pipeline, enabling the system to verify order execution (pre-hooks, swap, and post-hooks) before acceptance. It includes new configuration for simulation modes (Shadow and Enforce) and timeouts, as well as an adapter to bridge the order simulator with the validation logic. Feedback was provided regarding the build_preview_order_for_sim function, which currently initializes metadata with default values; the settlement_contract address must be explicitly passed to ensure simulations correctly reflect the target environment.
| fn build_preview_order_for_sim( | ||
| data: &OrderData, | ||
| interactions: &Interactions, | ||
| owner: Address, | ||
| uid: OrderUid, | ||
| signature: Signature, | ||
| ) -> Order { | ||
| Order { | ||
| metadata: OrderMetadata { | ||
| owner, | ||
| uid, | ||
| ..Default::default() | ||
| }, | ||
| data: *data, | ||
| signature, | ||
| interactions: interactions.clone(), | ||
| } | ||
| } |
There was a problem hiding this comment.
The build_preview_order_for_sim function initializes OrderMetadata with Default::default(), which leaves the settlement_contract field as Address::ZERO. This will cause simulation failures if the simulation logic or hooks rely on the correct settlement contract address (e.g., to determine the transaction recipient). Pass the settlement_contract address as an argument and set it in the metadata.
| fn build_preview_order_for_sim( | |
| data: &OrderData, | |
| interactions: &Interactions, | |
| owner: Address, | |
| uid: OrderUid, | |
| signature: Signature, | |
| ) -> Order { | |
| Order { | |
| metadata: OrderMetadata { | |
| owner, | |
| uid, | |
| ..Default::default() | |
| }, | |
| data: *data, | |
| signature, | |
| interactions: interactions.clone(), | |
| } | |
| } | |
| fn build_preview_order_for_sim( | |
| data: &OrderData, | |
| interactions: &Interactions, | |
| owner: Address, | |
| uid: OrderUid, | |
| signature: Signature, | |
| settlement_contract: Address, | |
| ) -> Order { | |
| Order { | |
| metadata: OrderMetadata { | |
| owner, | |
| uid, | |
| settlement_contract, | |
| ..Default::default() | |
| }, | |
| data: *data, | |
| signature, | |
| interactions: interactions.clone(), | |
| } | |
| } |
There was a problem hiding this comment.
The simulator does not read order.metadata.settlement_contract. SwapSimulator holds its own GPv2Settlement instance captured at construction (crates/simulator/src/swap_simulator.rs:67) and uses that address directly — e.g. as the balance-override holder in crates/orderbook/src/order_simulator.rs:257. Across the workspace, the only reader of that metadata field is the DB persistence path (crates/orderbook/src/database/orders.rs:175), which this preview Order never reaches. Leaving as is.
| let preview_order = build_preview_order_for_sim( | ||
| &data, | ||
| &app_data.interactions, | ||
| owner, | ||
| uid, | ||
| order.signature.clone(), | ||
| ); |
There was a problem hiding this comment.
Pass the settlement_contract address to build_preview_order_for_sim to ensure the simulation metadata is correctly populated.
| let preview_order = build_preview_order_for_sim( | |
| &data, | |
| &app_data.interactions, | |
| owner, | |
| uid, | |
| order.signature.clone(), | |
| ); | |
| let preview_order = build_preview_order_for_sim( | |
| &data, | |
| &app_data.interactions, | |
| owner, | |
| uid, | |
| order.signature.clone(), | |
| settlement_contract, | |
| ); |
|
@claude review |
|
Claude finished @squadgazzz's task in 4m 35s —— View job Code Review
Overall this is a solid PR — clean trait abstraction, concurrent signature + simulation via Timer measures join latency, not simulation latency
let (signature_res, simulation_res) = {
let _timer = Eip1271SimulationMetrics::get()
.simulation_time
.start_timer();
tokio::join!(signature_fut, simulation_fut)
};The Contrast with let _timer = Eip1271SimulationMetrics::get().simulation_time.start_timer();
tokio::time::timeout(config.timeout, sim.simulate(preview_order)).awaitFix: start the timer inside Log level inconsistency between the two simulation paths
SimulationOutcome::Fail { reason, tenderly_url } => tracing::info!(
order_uid = %preview_order.metadata.uid,
?reason,
?tenderly_url,
"eip1271 simulation (signature check skipped)",
),But Missing config test for
|
- Move simulation_time histogram timer inside simulation_fut so it no longer captures the max of sig-check + simulation latency. - Warn-level (not info) for simulation failures in the eip1271_skip_creation_validation path, matching the normal path. - Drop Default derive on the shared Eip1271SimulationMode since the operational default lives in configs (Disabled) and no code reaches for it. Updated the doc to clarify the split. - New configs test asserting "shadow" deserializes to the Shadow variant.
| /// Mode for the EIP-1271 order simulation at order creation. | ||
| #[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)] | ||
| #[serde(rename_all = "kebab-case")] | ||
| pub enum Eip1271SimulationMode { | ||
| Shadow, | ||
| Enforce, | ||
| #[default] | ||
| Disabled, | ||
| } |
There was a problem hiding this comment.
Shouldn't this be part of either the configs crate or the order simulator?
There was a problem hiding this comment.
It is in configs, this is the enum you're commenting on. The copy in shared::order_validation is only there because OrderValidator lives in shared and shared can't depend on configs, so we mirror plus convert at the call site in run.rs. Once Martin's simulator refactor lands and OrderSimulator moves out of orderbook, we can probably collapse both into one enum.
| } | ||
| } | ||
|
|
||
| fn build_1271_validator( |
There was a problem hiding this comment.
given you're adding expectations to the mocks, documenting them here with the "why" would be helpful
| let label = format!("sig={sig:?} sim={simulation:?} mode={mode:?}"); | ||
| let mut signature_validator = MockSignatureValidating::new(); | ||
| signature_validator | ||
| .expect_validate_signature_and_get_additional_gas() | ||
| .returning(move |_| match sig { | ||
| Sig::Pass => Ok(0u64), | ||
| Sig::Invalid => Err(SignatureValidationError::Invalid), | ||
| }); | ||
| let mut sim = MockEip1271Simulating::new(); | ||
| sim.expect_simulate().returning(move |_| match simulation { | ||
| Sim::Pass => Ok(()), | ||
| Sim::Reverted => Err(Eip1271SimulationError::Reverted { | ||
| reason: "hook reverted".into(), | ||
| tenderly_url: None, | ||
| }), | ||
| }); | ||
| let validator = build_1271_validator( | ||
| signature_validator, | ||
| Some(simulator_with_mode(sim, mode)), | ||
| false, | ||
| ); | ||
| let result = validator | ||
| .validate_and_construct_order( | ||
| make_1271_order_creation(), | ||
| &DomainSeparator::default(), | ||
| Default::default(), | ||
| None, | ||
| ) | ||
| .await; | ||
| match expected { | ||
| Expected::Accepted => assert!(result.is_ok(), "{label}: got {result:?}"), | ||
| Expected::InvalidSignature => assert!( | ||
| matches!(result, Err(ValidationError::InvalidEip1271Signature(_))), | ||
| "{label}: got {result:?}" | ||
| ), | ||
| Expected::SimulationFailed => assert!( | ||
| matches!(result, Err(ValidationError::SimulationFailed(_))), | ||
| "{label}: got {result:?}" | ||
| ), | ||
| } | ||
| } |
There was a problem hiding this comment.
Separating the test from the matrix would make sense to me + trying to add info on which combination failed and where to ease debugging
There was a problem hiding this comment.
The matrix is the result of collapsing 7 nearly identical tests. Every assert! message includes sig={sig:?} sim={simulation:?} mode={mode:?}, so a failure tells you exactly which cell blew up.
| // Build an EOA (Eip712) order and a simulator configured in enforce mode. | ||
| // The simulator should NOT be called — only EIP-1271 orders go through | ||
| // the simulation path. |
There was a problem hiding this comment.
the other mocks in this file could also get a note like this
…ture_check Same separation now used in run_eip1271_simulation_only. The simulation future returns the raw timeout Result; the outer match splits timeout (Infra) from the inner classification.
# Description The Claude code-review action currently posts one big summary as an issue comment (example: cowprotocol#4355 (comment)). The `code-review` plugin has a `--comment` flag that switches it from the summary-text mode to posting one PR review comment per issue anchored to the exact line in the diff, matching how `gemini-code-assist[bot]` already behaves. Per the plugin spec (step 9), this flag triggers `mcp__github_inline_comment__create_inline_comment` instead of a single `gh pr comment`. # Changes - Append `--comment` to the `/code-review:code-review` prompt in `.github/workflows/claude-code-review.yml`. # How to test Run the `claude /review` command.
jmg-duarte
left a comment
There was a problem hiding this comment.
No blockers from me, just small nits/improvements
- today's -> current in run_eip1271_with_signature_check docstring - Add timed_simulation helper folding metrics, timeout, and outcome classification. Replaces the duplicated wrapper at both call sites. - Extract calculate_verification_gas_limit so the EIP-1271 setup no longer lives inline in validate_and_construct_order.
| #[async_trait] | ||
| impl Eip1271Simulating for OrderSimulatorAdapter { | ||
| async fn simulate(&self, order: &Order) -> Result<(), Eip1271SimulationError> { | ||
| let swap = self.inner.encode_order(order, Vec::new(), None).await?; |
There was a problem hiding this comment.
How does this logic work for Aave orders that require a flashloan and are routed via the FlashloanWrapper contract instead of directly via GPv2Settlement?
If we don't pass in any wrappers here, how will it encode the transaction in a way that simulation can pass?
Is there a way we can test this case in our existing Aave integration test?
There was a problem hiding this comment.
Other simulations side stepped the flashloan aspect by using state overrides to make the sell_tokens appear. But I also can't find this logic in the current PR.
The overhaul of the simulation crate I'm working on would add proper support for flashloans as well.
| Infra(anyhow::Error), | ||
| } | ||
|
|
||
| /// Simulates an EIP-1271 order end-to-end (pre-hooks → swap → post-hooks). |
There was a problem hiding this comment.
This is not really correct. We don't actually simulate the full swap logic, in particular the isValidSignature is replaced by Trader.sol which always returns the magic value (true).
I think it would actually be nice if we could use only one method to verify everything end-to-end, instead of having one code path for signature verification and one for pre/post hooks, but I'm not sure what's all required for this.
At least for the signature check, we'd need to delegatecall into the original smart contract bytecode (for EIP1271 orders).
There was a problem hiding this comment.
The current simulator logic seems to have preferred building something that usable for the quote verification over the "normal" simulation case where we basically have to do no trickery. This makes the logic here quite awkward. I'm currently working on a simulation builder that makes it flexible enough to work with the quote verification and the normal use case to make it clear what's needed for what.
There was a problem hiding this comment.
Ok, we should at least address this comment then to not overpromise what this trait does.
|
in favor of #4366 |
Problem
Orderbook accepts an EIP-1271 order if the signer contract's
isValidSignaturesays yes. That single check is enough to let through Aave flashloan-style orders where the signature passes but the order's post-hook can never settle. We catch these later, after they're in an auction, wasting solver cycles.A symmetric class (Euler-style) is too strict on signatures but would actually settle fine. Those are rejected today. This PR doesn't change that, but the new metrics make them visible.
Fix
Wire the existing
OrderSimulator(today reachable only via/debug/simulation) into order creation. At accept time we now run the signature check and a full-order simulation concurrently, then combine the results.Per chain, a mode config picks what to do with the combined result:
disabled(default): same as today. Signature check only.shadow: both run, disagreements land in metrics and logs, behaviour unchanged.enforce: if signature passes but simulation fails, reject with HTTP 400Eip1271SimulationFailed.Infra errors (RPC, timeout, Tenderly) never reject in any mode. Signature-fail cases return the same
InvalidEip1271Signatureas before, regardless of the simulation result.Old path: one check, signature only. New path: signature plus simulation, with a per-chain knob to decide what to do when they disagree.
Rollout
Land as
disabled. Flip toshadowin prod to see the matrix on real traffic. Flip toenforceonce we trust the sim and the simulator refactor lands.Changes
shared::order_validation: newEip1271Simulatingtrait,Eip1271Simulatorbundle (simulator + mode + timeout) threaded intoOrderValidator. Validation runs signature + sim concurrently viatokio::join!with a per-call timeout, emits aneip1271_simulation_total{signature, simulation}Prometheus matrix, upgrades enforce-mode disagreements toValidationError::SimulationFailed.orderbook::eip1271_simulation:OrderSimulatorAdapterwrapping the existingOrderSimulator. Temporary shim untilcrates/simulatorownsOrderSimulator.configs:Eip1271SimulationModeenum withdisabled,shadow,enforcevariants.How to test
Unit tests in
order_validationcover the signature × simulation matrix, enforce-mode rejection, fail-open on infra errors, theeip1271_skip_creation_validationpath, and the no-simulator-configured path.configstests deserialize the three modes.